@open-agent-toolkit/cli 0.2.75 → 0.2.77

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 (48) hide show
  1. package/assets/bundle-metadata.json +1 -1
  2. package/assets/docs/cli-utilities/config-and-local-state.md +3 -3
  3. package/assets/docs/cli-utilities/configuration.md +3 -1
  4. package/assets/docs/cli-utilities/index.md +1 -0
  5. package/assets/docs/cli-utilities/tool-packs.md +1 -1
  6. package/assets/docs/reference/file-locations.md +1 -1
  7. package/assets/docs/reference/oat-directory-structure.md +1 -1
  8. package/assets/public-package-versions.json +4 -4
  9. package/assets/skills/explainer-kit/SKILL.md +1 -1
  10. package/assets/skills/explainer-kit/scripts/bundle.mjs +26 -2
  11. package/assets/skills/explainer-kit/scripts/record.mjs +26 -3
  12. package/assets/skills/explainer-kit/scripts/verify.mjs +26 -3
  13. package/assets/skills/oat-doctor/SKILL.md +153 -193
  14. package/assets/skills/oat-explainer-kit/SKILL.md +1 -1
  15. package/assets/skills/oat-explainer-kit/scripts/check-terminal-outcome.mjs +25 -5
  16. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +1 -1
  17. package/assets/skills/oat-project-complete/SKILL.md +1 -1
  18. package/assets/skills/oat-project-complete/scripts/consume-persisted-recap-intent.mjs +26 -6
  19. package/assets/skills/oat-project-complete/scripts/execute-synced-archive-entry.mjs +26 -6
  20. package/assets/skills/oat-project-complete/scripts/finalize-synced-archive.mjs +7 -0
  21. package/assets/skills/oat-project-complete/scripts/parse-completion-retry-fields.mjs +26 -5
  22. package/assets/skills/oat-project-complete/scripts/parse-synced-archive-resume-fields.mjs +26 -5
  23. package/assets/skills/oat-project-complete/scripts/recover-completion-receipts.mjs +26 -6
  24. package/assets/skills/oat-project-complete/scripts/resolve-completion-retry.mjs +26 -6
  25. package/assets/skills/oat-project-complete/scripts/resolve-synced-archive-entry.mjs +26 -5
  26. package/assets/skills/oat-project-complete/scripts/validate-durable-archive-receipt.mjs +7 -0
  27. package/assets/skills/oat-project-complete/scripts/validate-nonarchive-lifecycle-receipt.mjs +126 -95
  28. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +1 -1
  29. package/assets/skills/oat-project-implement/SKILL.md +1 -1
  30. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +1 -1
  31. package/assets/skills/oat-project-implement/scripts/capture-dirty-tree.mjs +3 -0
  32. package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +1 -1
  33. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +1 -1
  34. package/assets/skills/oat-project-quick-start/SKILL.md +9 -6
  35. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +1 -1
  36. package/assets/skills/recon/SKILL.md +1 -1
  37. package/assets/skills/recon/scripts/create-review-brief.mjs +25 -5
  38. package/assets/skills/recon/scripts/prepare-routing.mjs +26 -2
  39. package/assets/skills/recon/scripts/render-packet.mjs +25 -5
  40. package/assets/skills/recon/scripts/validate-artifact.mjs +25 -5
  41. package/assets/skills/recon/scripts/validate-packet.mjs +25 -5
  42. package/assets/templates/implementation.md +1 -1
  43. package/dist/commands/config/index.d.ts.map +1 -1
  44. package/dist/commands/config/index.js +18 -1
  45. package/dist/config/oat-config.d.ts +1 -0
  46. package/dist/config/oat-config.d.ts.map +1 -1
  47. package/dist/config/oat-config.js +1 -1
  48. package/package.json +2 -2
@@ -5,7 +5,7 @@ disable-model-invocation: true
5
5
  user-invocable: true
6
6
  allowed-tools: Read, Write, Bash, AskUserQuestion
7
7
  metadata:
8
- version: 1.7.12
8
+ version: 1.7.13
9
9
  ---
10
10
 
11
11
  # Complete Project
@@ -1,6 +1,7 @@
1
+ import { realpathSync } from 'node:fs';
1
2
  import { readdir, realpath } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
- import { pathToFileURL } from 'node:url';
3
+ import { join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
4
5
 
5
6
  import { readPersistedIntent } from '../../oat-explainer-kit/scripts/persist-intent.mjs';
6
7
 
@@ -110,10 +111,29 @@ async function main(argv) {
110
111
  return consumePersistedRecapIntent({ projectPath: argv[0] });
111
112
  }
112
113
 
113
- if (
114
- process.argv[1] &&
115
- import.meta.url === pathToFileURL(process.argv[1]).href
116
- ) {
114
+ /**
115
+ * Direct invocation, compared as canonical paths on both sides. Comparing a raw
116
+ * `process.argv[1]` against `import.meta.url` makes this script a silent no-op
117
+ * that exits 0 whenever the skill is reached through a symlinked install root,
118
+ * and canonicalizing only one side has the same effect under
119
+ * `--preserve-symlinks-main`, which keeps the link in `import.meta.url`.
120
+ * A path that cannot be canonicalized is not a module Node loaded as the entry
121
+ * point, so a thrown `realpathSync` means "not invoked directly" and returns
122
+ * `false`; it never masks a direct run.
123
+ */
124
+ function isDirectInvocation(invokedPath) {
125
+ if (!invokedPath) return false;
126
+ try {
127
+ return (
128
+ realpathSync(fileURLToPath(import.meta.url)) ===
129
+ realpathSync(resolve(invokedPath))
130
+ );
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ if (isDirectInvocation(process.argv[1])) {
117
137
  main(process.argv.slice(2))
118
138
  .then((result) => process.stdout.write(`${JSON.stringify(result)}\n`))
119
139
  .catch((error) => {
@@ -1,7 +1,8 @@
1
1
  import { execFile as execFileCallback } from 'node:child_process';
2
+ import { realpathSync } from 'node:fs';
2
3
  import { readFile } from 'node:fs/promises';
3
- import { isAbsolute, posix, relative, sep } from 'node:path';
4
- import { pathToFileURL } from 'node:url';
4
+ import { isAbsolute, posix, relative, sep, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
5
6
  import { promisify } from 'node:util';
6
7
 
7
8
  import { validateSyncedArchiveTerminalReport } from './finalize-synced-archive.mjs';
@@ -261,10 +262,29 @@ async function main(argv) {
261
262
  });
262
263
  }
263
264
 
264
- if (
265
- process.argv[1] &&
266
- import.meta.url === pathToFileURL(process.argv[1]).href
267
- ) {
265
+ /**
266
+ * Direct invocation, compared as canonical paths on both sides. Comparing a raw
267
+ * `process.argv[1]` against `import.meta.url` makes this script a silent no-op
268
+ * that exits 0 whenever the skill is reached through a symlinked install root,
269
+ * and canonicalizing only one side has the same effect under
270
+ * `--preserve-symlinks-main`, which keeps the link in `import.meta.url`.
271
+ * A path that cannot be canonicalized is not a module Node loaded as the entry
272
+ * point, so a thrown `realpathSync` means "not invoked directly" and returns
273
+ * `false`; it never masks a direct run.
274
+ */
275
+ function isDirectInvocation(invokedPath) {
276
+ if (!invokedPath) return false;
277
+ try {
278
+ return (
279
+ realpathSync(fileURLToPath(import.meta.url)) ===
280
+ realpathSync(resolve(invokedPath))
281
+ );
282
+ } catch {
283
+ return false;
284
+ }
285
+ }
286
+
287
+ if (isDirectInvocation(process.argv[1])) {
268
288
  main(process.argv.slice(2))
269
289
  .then((result) => process.stdout.write(`${JSON.stringify(result)}\n`))
270
290
  .catch((error) => {
@@ -92,6 +92,10 @@ async function main(argv) {
92
92
  projectName,
93
93
  getArchiveReport: async () => {
94
94
  try {
95
+ // Stdin is read to EOF with no timeout. Every caller pipes the archive
96
+ // report in (`printf '%s\n' "$ARCHIVE_OUTPUT" | node ...`), so EOF
97
+ // always arrives; a timeout would turn a slow producer into a false
98
+ // failure, and a caller that leaves stdin open is a caller bug.
95
99
  return JSON.parse(readFileSync(0, 'utf8'));
96
100
  } catch (error) {
97
101
  throw finalizationError(
@@ -119,6 +123,9 @@ async function main(argv) {
119
123
  * `--preserve-symlinks-main`, which keeps the link in `import.meta.url`. Either
120
124
  * way the caller reads "exited 0" as "verified" — the fail-open shape this
121
125
  * finalizer exists to prevent.
126
+ * A path that cannot be canonicalized is not a module Node loaded as the entry
127
+ * point, so a thrown `realpathSync` means "not invoked directly" and returns
128
+ * `false`; it never masks a direct run.
122
129
  */
123
130
  function isDirectInvocation(invokedPath) {
124
131
  if (!invokedPath) return false;
@@ -1,4 +1,6 @@
1
- import { pathToFileURL } from 'node:url';
1
+ import { realpathSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
2
4
 
3
5
  const FULL_SHA = /^[0-9a-f]{40}$/;
4
6
  const PR_ARTIFACT = /^pr\/project-pr-[^/\s]+\.md$/;
@@ -217,10 +219,29 @@ function main(argv) {
217
219
  return parseCompletionRetryFields(argv[0]);
218
220
  }
219
221
 
220
- if (
221
- process.argv[1] &&
222
- import.meta.url === pathToFileURL(process.argv[1]).href
223
- ) {
222
+ /**
223
+ * Direct invocation, compared as canonical paths on both sides. Comparing a raw
224
+ * `process.argv[1]` against `import.meta.url` makes this script a silent no-op
225
+ * that exits 0 whenever the skill is reached through a symlinked install root,
226
+ * and canonicalizing only one side has the same effect under
227
+ * `--preserve-symlinks-main`, which keeps the link in `import.meta.url`.
228
+ * A path that cannot be canonicalized is not a module Node loaded as the entry
229
+ * point, so a thrown `realpathSync` means "not invoked directly" and returns
230
+ * `false`; it never masks a direct run.
231
+ */
232
+ function isDirectInvocation(invokedPath) {
233
+ if (!invokedPath) return false;
234
+ try {
235
+ return (
236
+ realpathSync(fileURLToPath(import.meta.url)) ===
237
+ realpathSync(resolve(invokedPath))
238
+ );
239
+ } catch {
240
+ return false;
241
+ }
242
+ }
243
+
244
+ if (isDirectInvocation(process.argv[1])) {
224
245
  try {
225
246
  process.stdout.write(`${main(process.argv.slice(2))}\n`);
226
247
  } catch (error) {
@@ -1,4 +1,6 @@
1
- import { pathToFileURL } from 'node:url';
1
+ import { realpathSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
2
4
 
3
5
  function fieldsError(message) {
4
6
  const error = new Error(message);
@@ -92,10 +94,29 @@ function main(argv) {
92
94
  return formatShellAssignments(parseSyncedArchiveResumeFields(result));
93
95
  }
94
96
 
95
- if (
96
- process.argv[1] &&
97
- import.meta.url === pathToFileURL(process.argv[1]).href
98
- ) {
97
+ /**
98
+ * Direct invocation, compared as canonical paths on both sides. Comparing a raw
99
+ * `process.argv[1]` against `import.meta.url` makes this script a silent no-op
100
+ * that exits 0 whenever the skill is reached through a symlinked install root,
101
+ * and canonicalizing only one side has the same effect under
102
+ * `--preserve-symlinks-main`, which keeps the link in `import.meta.url`.
103
+ * A path that cannot be canonicalized is not a module Node loaded as the entry
104
+ * point, so a thrown `realpathSync` means "not invoked directly" and returns
105
+ * `false`; it never masks a direct run.
106
+ */
107
+ function isDirectInvocation(invokedPath) {
108
+ if (!invokedPath) return false;
109
+ try {
110
+ return (
111
+ realpathSync(fileURLToPath(import.meta.url)) ===
112
+ realpathSync(resolve(invokedPath))
113
+ );
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ if (isDirectInvocation(process.argv[1])) {
99
120
  try {
100
121
  process.stdout.write(`${main(process.argv.slice(2))}\n`);
101
122
  } catch (error) {
@@ -1,6 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { basename } from 'node:path';
3
- import { pathToFileURL } from 'node:url';
2
+ import { realpathSync } from 'node:fs';
3
+ import { basename, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
4
5
  import { promisify } from 'node:util';
5
6
 
6
7
  const execFileAsync = promisify(execFile);
@@ -692,10 +693,29 @@ async function main(argv) {
692
693
  return recoverCompletionReceipts(options);
693
694
  }
694
695
 
695
- if (
696
- process.argv[1] &&
697
- import.meta.url === pathToFileURL(process.argv[1]).href
698
- ) {
696
+ /**
697
+ * Direct invocation, compared as canonical paths on both sides. Comparing a raw
698
+ * `process.argv[1]` against `import.meta.url` makes this script a silent no-op
699
+ * that exits 0 whenever the skill is reached through a symlinked install root,
700
+ * and canonicalizing only one side has the same effect under
701
+ * `--preserve-symlinks-main`, which keeps the link in `import.meta.url`.
702
+ * A path that cannot be canonicalized is not a module Node loaded as the entry
703
+ * point, so a thrown `realpathSync` means "not invoked directly" and returns
704
+ * `false`; it never masks a direct run.
705
+ */
706
+ function isDirectInvocation(invokedPath) {
707
+ if (!invokedPath) return false;
708
+ try {
709
+ return (
710
+ realpathSync(fileURLToPath(import.meta.url)) ===
711
+ realpathSync(resolve(invokedPath))
712
+ );
713
+ } catch {
714
+ return false;
715
+ }
716
+ }
717
+
718
+ if (isDirectInvocation(process.argv[1])) {
699
719
  main(process.argv.slice(2))
700
720
  .then((result) => {
701
721
  process.stdout.write(`${JSON.stringify(result)}\n`);
@@ -1,6 +1,7 @@
1
+ import { realpathSync } from 'node:fs';
1
2
  import { lstat, readdir } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
- import { pathToFileURL } from 'node:url';
3
+ import { join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
4
5
 
5
6
  import {
6
7
  detectCompletionReceiptCandidate,
@@ -116,10 +117,29 @@ async function main(argv) {
116
117
  return resolveCompletionRetry(parseArguments(argv));
117
118
  }
118
119
 
119
- if (
120
- process.argv[1] &&
121
- import.meta.url === pathToFileURL(process.argv[1]).href
122
- ) {
120
+ /**
121
+ * Direct invocation, compared as canonical paths on both sides. Comparing a raw
122
+ * `process.argv[1]` against `import.meta.url` makes this script a silent no-op
123
+ * that exits 0 whenever the skill is reached through a symlinked install root,
124
+ * and canonicalizing only one side has the same effect under
125
+ * `--preserve-symlinks-main`, which keeps the link in `import.meta.url`.
126
+ * A path that cannot be canonicalized is not a module Node loaded as the entry
127
+ * point, so a thrown `realpathSync` means "not invoked directly" and returns
128
+ * `false`; it never masks a direct run.
129
+ */
130
+ function isDirectInvocation(invokedPath) {
131
+ if (!invokedPath) return false;
132
+ try {
133
+ return (
134
+ realpathSync(fileURLToPath(import.meta.url)) ===
135
+ realpathSync(resolve(invokedPath))
136
+ );
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ if (isDirectInvocation(process.argv[1])) {
123
143
  main(process.argv.slice(2))
124
144
  .then((result) => {
125
145
  process.stdout.write(`${JSON.stringify(result)}\n`);
@@ -1,6 +1,8 @@
1
1
  import { execFile as execFileCallback } from 'node:child_process';
2
+ import { realpathSync } from 'node:fs';
2
3
  import { readFile } from 'node:fs/promises';
3
- import { pathToFileURL } from 'node:url';
4
+ import { resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
4
6
  import { promisify } from 'node:util';
5
7
 
6
8
  const execFile = promisify(execFileCallback);
@@ -149,10 +151,29 @@ async function main(argv) {
149
151
  return resolveSyncedArchiveEntry({ ...options, record });
150
152
  }
151
153
 
152
- if (
153
- process.argv[1] &&
154
- import.meta.url === pathToFileURL(process.argv[1]).href
155
- ) {
154
+ /**
155
+ * Direct invocation, compared as canonical paths on both sides. Comparing a raw
156
+ * `process.argv[1]` against `import.meta.url` makes this script a silent no-op
157
+ * that exits 0 whenever the skill is reached through a symlinked install root,
158
+ * and canonicalizing only one side has the same effect under
159
+ * `--preserve-symlinks-main`, which keeps the link in `import.meta.url`.
160
+ * A path that cannot be canonicalized is not a module Node loaded as the entry
161
+ * point, so a thrown `realpathSync` means "not invoked directly" and returns
162
+ * `false`; it never masks a direct run.
163
+ */
164
+ function isDirectInvocation(invokedPath) {
165
+ if (!invokedPath) return false;
166
+ try {
167
+ return (
168
+ realpathSync(fileURLToPath(import.meta.url)) ===
169
+ realpathSync(resolve(invokedPath))
170
+ );
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ if (isDirectInvocation(process.argv[1])) {
156
177
  main(process.argv.slice(2))
157
178
  .then((result) => process.stdout.write(`${JSON.stringify(result)}\n`))
158
179
  .catch((error) => {
@@ -134,6 +134,10 @@ async function main(argv) {
134
134
  if (options.mode === 'receipt') {
135
135
  let report;
136
136
  try {
137
+ // Stdin is read to EOF with no timeout. Every caller pipes the archive
138
+ // report in (`printf '%s\n' "$ARCHIVE_OUTPUT" | node ...`), so EOF
139
+ // always arrives; a timeout would turn a slow producer into a false
140
+ // failure, and a caller that leaves stdin open is a caller bug.
137
141
  report = JSON.parse(readFileSync(0, 'utf8'));
138
142
  } catch (error) {
139
143
  throw receiptError(
@@ -158,6 +162,9 @@ async function main(argv) {
158
162
  * `--preserve-symlinks-main`, which keeps the link in `import.meta.url`. Either
159
163
  * way the caller reads "exited 0" as "verified" — the fail-open shape this
160
164
  * validator exists to prevent.
165
+ * A path that cannot be canonicalized is not a module Node loaded as the entry
166
+ * point, so a thrown `realpathSync` means "not invoked directly" and returns
167
+ * `false`; it never masks a direct run.
161
168
  */
162
169
  function isDirectInvocation(invokedPath) {
163
170
  if (!invokedPath) return false;
@@ -11,6 +11,7 @@ import {
11
11
  resolve,
12
12
  sep,
13
13
  } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
14
15
 
15
16
  function fail(message) {
16
17
  process.stderr.write(`Invalid non-archive lifecycle receipt: ${message}\n`);
@@ -57,104 +58,134 @@ function gitSucceeds(cwd, args) {
57
58
  }
58
59
  }
59
60
 
60
- const [recordArgument, commit, projectName] = process.argv.slice(2);
61
- if (!recordArgument || !/^[0-9a-f]{40}$/.test(commit ?? '') || !projectName) {
62
- fail('expected <record-path> <full-commit-sha> <project-name>.');
63
- }
61
+ function main(argv) {
62
+ const [recordArgument, commit, projectName] = argv;
63
+ if (!recordArgument || !/^[0-9a-f]{40}$/.test(commit ?? '') || !projectName) {
64
+ fail('expected <record-path> <full-commit-sha> <project-name>.');
65
+ }
64
66
 
65
- const unresolvedRecordPath = resolve(recordArgument);
66
- if (!existsSync(unresolvedRecordPath)) {
67
- fail('record path does not exist; restore the synced discovery record.');
68
- }
69
- const recordPath = realpathSync(unresolvedRecordPath);
70
- const repoRoot = realpathSync(
71
- git(dirname(recordPath), ['rev-parse', '--show-toplevel']),
72
- );
73
- const recordRelative = relative(repoRoot, recordPath);
74
- if (
75
- recordRelative === '' ||
76
- recordRelative === '..' ||
77
- recordRelative.startsWith(`..${sep}`) ||
78
- isAbsolute(recordRelative)
79
- ) {
80
- fail('record path is outside the repository root.');
81
- }
82
- const gitRecordPath = recordRelative.split(sep).join('/');
83
- if (basename(gitRecordPath, extname(gitRecordPath)) !== projectName) {
84
- fail('record filename must match the project name.');
85
- }
86
- if (!gitSucceeds(repoRoot, ['merge-base', '--is-ancestor', commit, 'HEAD'])) {
87
- fail('receipt commit must be an ancestor of the current repository HEAD.');
88
- }
89
- const changedPaths = git(repoRoot, [
90
- 'diff-tree',
91
- '--root',
92
- '--no-commit-id',
93
- '--name-only',
94
- '-r',
95
- '--format=',
96
- commit,
97
- ])
98
- .split('\n')
99
- .filter(Boolean);
100
- if (changedPaths.length !== 1 || changedPaths[0] !== gitRecordPath) {
101
- fail('receipt commit must change exactly the synced discovery record path.');
102
- }
103
- const expectedSubject = `chore(oat): complete synced project ${projectName}`;
104
- const subject = git(repoRoot, ['show', '-s', '--format=%s', commit]);
105
- if (subject !== expectedSubject) {
106
- fail(`commit subject must be "${expectedSubject}".`);
107
- }
67
+ const unresolvedRecordPath = resolve(recordArgument);
68
+ if (!existsSync(unresolvedRecordPath)) {
69
+ fail('record path does not exist; restore the synced discovery record.');
70
+ }
71
+ const recordPath = realpathSync(unresolvedRecordPath);
72
+ const repoRoot = realpathSync(
73
+ git(dirname(recordPath), ['rev-parse', '--show-toplevel']),
74
+ );
75
+ const recordRelative = relative(repoRoot, recordPath);
76
+ if (
77
+ recordRelative === '' ||
78
+ recordRelative === '..' ||
79
+ recordRelative.startsWith(`..${sep}`) ||
80
+ isAbsolute(recordRelative)
81
+ ) {
82
+ fail('record path is outside the repository root.');
83
+ }
84
+ const gitRecordPath = recordRelative.split(sep).join('/');
85
+ if (basename(gitRecordPath, extname(gitRecordPath)) !== projectName) {
86
+ fail('record filename must match the project name.');
87
+ }
88
+ if (!gitSucceeds(repoRoot, ['merge-base', '--is-ancestor', commit, 'HEAD'])) {
89
+ fail('receipt commit must be an ancestor of the current repository HEAD.');
90
+ }
91
+ const changedPaths = git(repoRoot, [
92
+ 'diff-tree',
93
+ '--root',
94
+ '--no-commit-id',
95
+ '--name-only',
96
+ '-r',
97
+ '--format=',
98
+ commit,
99
+ ])
100
+ .split('\n')
101
+ .filter(Boolean);
102
+ if (changedPaths.length !== 1 || changedPaths[0] !== gitRecordPath) {
103
+ fail(
104
+ 'receipt commit must change exactly the synced discovery record path.',
105
+ );
106
+ }
107
+ const expectedSubject = `chore(oat): complete synced project ${projectName}`;
108
+ const subject = git(repoRoot, ['show', '-s', '--format=%s', commit]);
109
+ if (subject !== expectedSubject) {
110
+ fail(`commit subject must be "${expectedSubject}".`);
111
+ }
108
112
 
109
- let committedRecordContents;
110
- let record;
111
- try {
112
- committedRecordContents = gitRaw(repoRoot, [
113
- 'show',
114
- `${commit}:${gitRecordPath}`,
113
+ let committedRecordContents;
114
+ let record;
115
+ try {
116
+ committedRecordContents = gitRaw(repoRoot, [
117
+ 'show',
118
+ `${commit}:${gitRecordPath}`,
119
+ ]);
120
+ record = JSON.parse(committedRecordContents);
121
+ } catch (error) {
122
+ fail(
123
+ `committed record is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
124
+ );
125
+ }
126
+ if (readFileSync(recordPath, 'utf8') !== committedRecordContents) {
127
+ fail('working-tree record content must exactly match the receipt commit.');
128
+ }
129
+ const expectedKeys = new Set([
130
+ 'archiveSnapshot',
131
+ 'completedAt',
132
+ 'createdAt',
133
+ 'ref',
134
+ 'remote',
135
+ 'schemaVersion',
136
+ 'scope',
137
+ 'slug',
138
+ 'status',
115
139
  ]);
116
- record = JSON.parse(committedRecordContents);
117
- } catch (error) {
118
- fail(
119
- `committed record is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
120
- );
121
- }
122
- if (readFileSync(recordPath, 'utf8') !== committedRecordContents) {
123
- fail('working-tree record content must exactly match the receipt commit.');
140
+ if (
141
+ !record ||
142
+ typeof record !== 'object' ||
143
+ Array.isArray(record) ||
144
+ Object.keys(record).some((key) => !expectedKeys.has(key)) ||
145
+ record.schemaVersion !== 1 ||
146
+ record.slug !== projectName ||
147
+ record.scope !== 'synced' ||
148
+ record.ref !== `refs/oat/projects/${projectName}` ||
149
+ record.remote !== 'origin' ||
150
+ record.status !== 'complete' ||
151
+ typeof record.createdAt !== 'string' ||
152
+ Number.isNaN(Date.parse(record.createdAt)) ||
153
+ typeof record.completedAt !== 'string' ||
154
+ Number.isNaN(Date.parse(record.completedAt)) ||
155
+ (record.archiveSnapshot !== undefined &&
156
+ (typeof record.archiveSnapshot !== 'string' ||
157
+ record.archiveSnapshot.length === 0))
158
+ ) {
159
+ fail(
160
+ 'committed record must be a complete synced record with the exact project identity and lifecycle content.',
161
+ );
162
+ }
163
+
164
+ process.stdout.write(`${commit}\n`);
124
165
  }
125
- const expectedKeys = new Set([
126
- 'archiveSnapshot',
127
- 'completedAt',
128
- 'createdAt',
129
- 'ref',
130
- 'remote',
131
- 'schemaVersion',
132
- 'scope',
133
- 'slug',
134
- 'status',
135
- ]);
136
- if (
137
- !record ||
138
- typeof record !== 'object' ||
139
- Array.isArray(record) ||
140
- Object.keys(record).some((key) => !expectedKeys.has(key)) ||
141
- record.schemaVersion !== 1 ||
142
- record.slug !== projectName ||
143
- record.scope !== 'synced' ||
144
- record.ref !== `refs/oat/projects/${projectName}` ||
145
- record.remote !== 'origin' ||
146
- record.status !== 'complete' ||
147
- typeof record.createdAt !== 'string' ||
148
- Number.isNaN(Date.parse(record.createdAt)) ||
149
- typeof record.completedAt !== 'string' ||
150
- Number.isNaN(Date.parse(record.completedAt)) ||
151
- (record.archiveSnapshot !== undefined &&
152
- (typeof record.archiveSnapshot !== 'string' ||
153
- record.archiveSnapshot.length === 0))
154
- ) {
155
- fail(
156
- 'committed record must be a complete synced record with the exact project identity and lifecycle content.',
157
- );
166
+
167
+ /**
168
+ * Direct invocation, compared as canonical paths on both sides. Comparing a raw
169
+ * `process.argv[1]` against `import.meta.url` makes this script a silent no-op
170
+ * that exits 0 whenever the skill is reached through a symlinked install root,
171
+ * and canonicalizing only one side has the same effect under
172
+ * `--preserve-symlinks-main`, which keeps the link in `import.meta.url`.
173
+ * A path that cannot be canonicalized is not a module Node loaded as the entry
174
+ * point, so a thrown `realpathSync` means "not invoked directly" and returns
175
+ * `false`; it never masks a direct run.
176
+ */
177
+ function isDirectInvocation(invokedPath) {
178
+ if (!invokedPath) return false;
179
+ try {
180
+ return (
181
+ realpathSync(fileURLToPath(import.meta.url)) ===
182
+ realpathSync(resolve(invokedPath))
183
+ );
184
+ } catch {
185
+ return false;
186
+ }
158
187
  }
159
188
 
160
- process.stdout.write(`${commit}\n`);
189
+ if (isDirectInvocation(process.argv[1])) {
190
+ main(process.argv.slice(2));
191
+ }