@sabaiway/agent-workflow-memory 8.3.0 → 8.4.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/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to the memory substrate. Versions are this **package's** npm
4
4
  they are distinct from the **deployment-lineage** stamp written into a project's
5
5
  `docs/ai/.memory-version` (which tracks the shared `agent-workflow` lineage, head `4.0.0`).
6
6
 
7
+ ## 8.4.0 — the commit hook runs your own scripts/pre-commit.local first (AD-195)
8
+
9
+ **Your own commit checks now have a place of their own: `scripts/pre-commit.local`.** Every hook that
10
+ `references/scripts/install-git-hooks.mjs` writes carries, right after `set -e`, a comment line naming that file and
11
+ `if [ -f scripts/pre-commit.local ]; then bash scripts/pre-commit.local; fi`, so the file runs with bash before the
12
+ docs and scripts lines.
13
+
14
+ - A non-zero exit of the file stops the commit; with no such file the hook runs as before.
15
+ - The installer never writes, reads or ships the file, and no flag turns the line off.
16
+ - A hook this installer wrote gains the line the next time the installer runs (`prepare`, or by hand); a hook it
17
+ did not write is refused as before.
18
+ - `install-git-hooks-repo-exec.test.mjs` proves it through real commits: the file runs before the docs check, an exit
19
+ 1 stops the commit, and an absent file lets it through.
20
+
21
+ MINOR: two new lines in every written hook.
22
+
7
23
  ## 8.3.0 — the hook installer arms the project-rules check and the layout check (AD-193)
8
24
 
9
25
  **`references/scripts/install-git-hooks.mjs` gains `--rules-check <path>` and `--layout-check <path>`.** Each writes
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-memory
3
3
  description: Deploy or upgrade a portable AI-agent memory substrate in any project — an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) and a structured `docs/ai/` context store with cap/archive/index enforcement. Use when the user wants to bootstrap `docs/ai/`, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-memory` / `/agent-workflow-memory upgrade`. Triggers on "set up the memory system", "deploy the AI memory here", "bootstrap docs/ai", "upgrade the memory substrate". This is the substrate only — the workflow methodology (plan→execute→review, queue, Cleanup) is owned elsewhere and injected into AGENTS.md by the family composition root.
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '8.3.0'
6
+ version: '8.4.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-memory
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-memory",
5
5
  "kind": "memory-substrate",
6
- "version": "8.3.0",
6
+ "version": "8.4.0",
7
7
  "provides": ["context"],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-memory",
3
- "version": "8.3.0",
3
+ "version": "8.4.0",
4
4
  "description": "Portable, cross-agent memory substrate for AI coding agents — an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement, deployable standalone or as part of the agent-workflow family. The memory layer of the agent-workflow family.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -4,12 +4,13 @@
4
4
  // deploy-copy suite (install-git-hooks.test.mjs) keeps the consumer-shaped behavior pins. Every
5
5
  // branch of the installer runs from here: fresh install, the guard arm/carry/disable/conflict
6
6
  // lanes, the rules-check and layout-check arm/carry/duplicate/refusal lanes (win32 included), the
7
- // unmanaged refusal, the up-to-date short-circuit, and the not-a-git-checkout skip. Every child
7
+ // unmanaged refusal, the up-to-date short-circuit, the not-a-git-checkout skip, and the S71
8
+ // real-commit lane (real `git commit` through the written hook over stand-in scripts). Every child
8
9
  // runs with each inherited GIT_* variable stripped, so a hook's environment never leaks in.
9
10
 
10
11
  import { describe, it } from 'node:test';
11
12
  import assert from 'node:assert/strict';
12
- import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, realpathSync } from 'node:fs';
13
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, realpathSync, chmodSync, statSync } from 'node:fs';
13
14
  import { tmpdir } from 'node:os';
14
15
  import { join, dirname } from 'node:path';
15
16
  import { fileURLToPath } from 'node:url';
@@ -130,3 +131,55 @@ describe('install-git-hooks — the in-place installer under a GIT_DIR-pinned fi
130
131
  rmSync(nowhere, { recursive: true, force: true });
131
132
  });
132
133
  });
134
+
135
+ // The commit lane also drops NODE_TEST_CONTEXT: an inherited one makes the hook's nested `node --test` skip every file.
136
+ const IDENTITY = { GIT_AUTHOR_NAME: 'p', GIT_AUTHOR_EMAIL: 'p@e', GIT_COMMITTER_NAME: 'p', GIT_COMMITTER_EMAIL: 'p@e' };
137
+ const bareAt = (home, extra = {}) => { const { NODE_TEST_CONTEXT: _, ...env } = envAt(home, { ...IDENTITY, ...extra }); return env; };
138
+ const passes = (r) => (assert.equal(r.status, 0, r.stderr), r);
139
+
140
+ // Passing stand-ins for every script the hook runs; check-docs-size.mjs, run right after the local line, records
141
+ // whether the marker existed. The marker and the record live in a sibling dir, outside the work tree.
142
+ const commitRepo = (root, side) => {
143
+ const [marker, record, local] = [join(side, 'marker'), join(side, 'record'), join(root, 'scripts', 'pre-commit.local')];
144
+ const git = (...args) => spawnChecked('git', args, { cwd: root, env: bareAt(root) });
145
+ mkdirSync(join(root, 'scripts'));
146
+ writeFileSync(join(root, 'scripts', 'check-docs-size.mjs'), `import { existsSync, writeFileSync } from 'node:fs';
147
+ if (!process.argv.includes('--check-index')) writeFileSync(${JSON.stringify(record)}, String(existsSync(${JSON.stringify(marker)})));\n`);
148
+ for (const name of ['archive-changelog', 'archive-issues', 'archive-decisions']) writeFileSync(join(root, 'scripts', `${name}.mjs`), 'process.exit(0);\n');
149
+ writeFileSync(join(root, 'scripts', 'stand-in.test.mjs'), "import { test } from 'node:test';\ntest('a passing stand-in', () => {});\n");
150
+ for (const args of [['init', '-q'], ['add', '-A'], ['commit', '-qm', 'base']]) passes(git(...args));
151
+ passes(spawnChecked(process.execPath, [INSTALLER], { env: bareAt(root, { GIT_DIR: join(root, '.git') }) }));
152
+ const head = () => git('rev-parse', 'HEAD').stdout.trim();
153
+ const commit = (body) => {
154
+ if (body !== undefined) { writeFileSync(local, body); chmodSync(local, 0o644); assert.equal(statSync(local).mode & 0o111, 0, 'the local file is not executable'); }
155
+ writeFileSync(join(root, 'change.txt'), 'change\n');
156
+ passes(git('add', 'change.txt'));
157
+ return git('commit', '-qm', 'change');
158
+ };
159
+ return { marker, record, head, commit };
160
+ };
161
+ const inCommitRepo = (cell) => () => {
162
+ const dirs = ['hooks-commit-', 'hooks-commit-side-'].map((prefix) => realpathSync(mkdtempSync(join(tmpdir(), prefix))));
163
+ try { cell(commitRepo(...dirs)); } finally { for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); }
164
+ };
165
+
166
+ describe('spec:init-project/S71 the local hook file through real commits over stand-ins', () => {
167
+ it('a local file that writes a marker runs before the docs check, and the commit exits 0', { timeout: 120_000 }, inCommitRepo((fx) => {
168
+ passes(fx.commit(`printf made > "${fx.marker}"\n`));
169
+ assert.ok(existsSync(fx.marker), 'the local file ran: its marker appeared');
170
+ assert.equal(readFileSync(fx.record, 'utf8'), 'true', 'the marker existed when check-docs-size.mjs ran');
171
+ }));
172
+
173
+ it('a local file that exits 1 stops the commit: non-zero, no new commit', { timeout: 120_000 }, inCommitRepo((fx) => {
174
+ const before = fx.head();
175
+ assert.notEqual(fx.commit('exit 1\n').status, 0, 'a local file exiting 1 stops the commit');
176
+ assert.equal(fx.head(), before, 'no new commit');
177
+ }));
178
+
179
+ it('with no local file the commit exits 0', { timeout: 120_000 }, inCommitRepo((fx) => {
180
+ const before = fx.head();
181
+ passes(fx.commit());
182
+ assert.notEqual(fx.head(), before, 'the commit landed');
183
+ assert.equal(readFileSync(fx.record, 'utf8'), 'false', 'the hook ran its stand-ins, with no marker');
184
+ }));
185
+ });
@@ -6,6 +6,7 @@
6
6
  // suite, so docs files cannot drift over their declared `maxLines`, the auto-generated
7
7
  // `index.md` navigator cannot silently fall out of sync, stale archive entries never
8
8
  // reach a commit, and regressions to the scripts themselves are caught at commit time.
9
+ // First after `set -e` it runs the project's own `scripts/pre-commit.local` with bash when present.
9
10
  //
10
11
  // The hooks location comes from git's OWN answer (`git rev-parse --git-path hooks`) — a linked
11
12
  // worktree resolves ITS OWN hooks path (its `.git` is a FILE; a hardcoded `.git/hooks` would
@@ -161,6 +162,8 @@ ${MAGIC_MARKER}
161
162
  # declared maxLines, the auto-generated index.md cannot silently go stale, stale
162
163
  # archive entries never reach a commit, and regressions to the scripts are caught.
163
164
  set -e
165
+ # Your own commit checks go in scripts/pre-commit.local; it runs first, with bash, when present.
166
+ if [ -f scripts/pre-commit.local ]; then bash scripts/pre-commit.local; fi
164
167
  node scripts/check-docs-size.mjs
165
168
  node scripts/check-docs-size.mjs --check-index
166
169
  node scripts/archive-changelog.mjs --check
@@ -59,7 +59,7 @@ const markerFor = (name) => `# ${name}:install-git-hooks.mjs`;
59
59
  const readHook = (root) => readFileSync(join(hooksPathOf(root), 'pre-commit'), 'utf8');
60
60
  const stub = (root, rel) => (mkdirSync(dirname(join(root, rel)), { recursive: true }), writeFileSync(join(root, rel), '// a stand-in tool\n'), join(root, rel));
61
61
 
62
- // The hook the 4.0.0-8.2.1 installer body writes with no guard: the six docs and scripts lines.
62
+ // The hook the 8.3.0 installer body writes with no guard: the six docs and scripts lines.
63
63
  const plainHook = (root) => `#!/usr/bin/env bash
64
64
  ${markerFor(basename(root))}
65
65
  # Auto-installed by scripts/install-git-hooks.mjs (run it from "prepare" or by hand).
@@ -75,6 +75,16 @@ node scripts/archive-issues.mjs --check
75
75
  node scripts/archive-decisions.mjs --check
76
76
  node --test scripts/*.test.mjs
77
77
  `;
78
+ const LOCAL_LINE = 'if [ -f scripts/pre-commit.local ]; then bash scripts/pre-commit.local; fi';
79
+ // The plain hook every write carries: the 8.3.0 hook with, right after set -e, one `# ` comment naming
80
+ // scripts/pre-commit.local (its wording is free, so it is read from the written hook) and the exact local line.
81
+ const localHook = (root) => {
82
+ const lines = readHook(root).split('\n');
83
+ const comment = lines[lines.indexOf('set -e') + 1];
84
+ assert.ok(comment?.startsWith('# ') && comment.includes('scripts/pre-commit.local'), `right after set -e a # comment names scripts/pre-commit.local, got ${JSON.stringify(comment)}`);
85
+ assert.deepEqual(lines.filter((l) => l.includes('scripts/pre-commit.local')), [comment, LOCAL_LINE], 'the comment and the local line are the only lines naming scripts/pre-commit.local');
86
+ return plainHook(root).replace('set -e\n', `set -e\n${comment}\n${LOCAL_LINE}\n`);
87
+ };
78
88
  const line = (path, role) => `node "${role ? shown(path) : path}" --check${role ? ` # ${role}` : ''}\n`;
79
89
 
80
90
  describe('install-git-hooks — beside no package.json, or a malformed one, the marker names the directory basename', () => {
@@ -148,8 +158,9 @@ describe('spec:init-project/S58 install-git-hooks — the rules-check and layout
148
158
  const [rules, layout, guard, guard2] = ['tools/rules-cli.mjs', 'tools/source-size-check.mjs', 'guard.mjs', 'g2/guard.mjs'].map((rel) => stub(root, rel));
149
159
  installs(root, ['--commit-guard', 'guard.mjs']);
150
160
  installs(root, ['--layout-check', 'tools/source-size-check.mjs']);
161
+ assert.equal(readHook(root), localHook(root) + line(layout, 'layout-check') + line(guard), 'the --layout-check run carried the guard line');
151
162
  installs(root, ['--rules-check', rules]);
152
- const armed = plainHook(root) + line(rules, 'rules-check') + line(layout, 'layout-check');
163
+ const armed = localHook(root) + line(rules, 'rules-check') + line(layout, 'layout-check');
153
164
  assert.equal(readHook(root), armed + line(guard), 'each arming run carried the other armed lines');
154
165
  assert.match(installs(root).stdout, /already up to date/, 'a flagless re-run keeps all three');
155
166
  assert.equal(readHook(root), armed + line(guard));
@@ -163,7 +174,7 @@ describe('spec:init-project/S58 install-git-hooks — the rules-check and layout
163
174
  const [legacy, rules] = ['legacy/rules-cli.mjs', 'tools/rules-cli.mjs'].map((rel) => stub(root, rel));
164
175
  installs(root, ['--commit-guard', legacy]);
165
176
  installs(root, ['--rules-check', rules]);
166
- const expected = plainHook(root) + line(rules, 'rules-check') + line(legacy);
177
+ const expected = localHook(root) + line(rules, 'rules-check') + line(legacy);
167
178
  assert.equal(readHook(root), expected);
168
179
  installs(root);
169
180
  assert.equal(readHook(root), expected, 'the flagless re-run keeps the legacy line as the guard');
@@ -183,11 +194,11 @@ describe('spec:init-project/S58 install-git-hooks — the rules-check and layout
183
194
  }
184
195
  }));
185
196
 
186
- it('over a hook with no rules or layout line, a flagless and a --commit-guard run write the 4.0.0-8.2.1 bytes', inProject(({ root }) => {
197
+ it('over a hook with no rules or layout line, a flagless and a --commit-guard run write the plain hook with the comment line and the local line right after set -e', inProject(({ root }) => {
187
198
  installs(root);
188
- assert.equal(readHook(root), plainHook(root));
199
+ assert.equal(readHook(root), localHook(root));
189
200
  installs(root, ['--commit-guard', 'guard.mjs']);
190
- assert.equal(readHook(root), plainHook(root) + line(join(root, 'guard.mjs')));
201
+ assert.equal(readHook(root), localHook(root) + line(join(root, 'guard.mjs')));
191
202
  }));
192
203
 
193
204
  it('a wrong file name, a shell-active path or a missing value exits 2, a missing file exits 1, each writing nothing', inProject(({ root }) => {
@@ -220,11 +231,11 @@ describe('spec:init-project/S58 install-git-hooks — the rules-check and layout
220
231
  const armedAt = stub(root, 'a/b/rules-cli.mjs');
221
232
  if (process.platform !== 'win32') { // there the backslash is refused, writing nothing
222
233
  const posix = runInstaller(root, ['--rules-check', given]);
223
- assert.deepEqual([posix.status, /a backslash, CR or LF/.test(posix.stderr), readHook(root)], [2, true, plainHook(root)], posix.stderr);
234
+ assert.deepEqual([posix.status, /a backslash, CR or LF/.test(posix.stderr), readHook(root)], [2, true, localHook(root)], posix.stderr);
224
235
  }
225
236
  installs(root, ['--rules-check', given], WIN32);
226
- assert.equal(readHook(root), plainHook(root) + line(armedAt, 'rules-check'));
237
+ assert.equal(readHook(root), localHook(root) + line(armedAt, 'rules-check'));
227
238
  assert.match(installs(root).stdout, /already up to date/, 'the flagless re-run recognises the win32-armed line');
228
- assert.equal(readHook(root), plainHook(root) + line(armedAt, 'rules-check'));
239
+ assert.equal(readHook(root), localHook(root) + line(armedAt, 'rules-check'));
229
240
  }));
230
241
  });