@naswerks/engineering-loop 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +15 -0
- package/.claude-plugin/plugin.json +8 -0
- package/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/bin/verify-staged.mjs +247 -0
- package/package.json +35 -0
- package/references/docs-workflow.md +417 -0
- package/references/engineering-loop.md +298 -0
- package/skills/docs-audit-feature/SKILL.md +88 -0
- package/skills/docs-audit-full/SKILL.md +54 -0
- package/skills/docs-backlog/SKILL.md +41 -0
- package/skills/docs-process/SKILL.md +146 -0
- package/skills/docs-status/SKILL.md +47 -0
- package/skills/docs-write/SKILL.md +43 -0
- package/skills/init/SKILL.md +163 -0
- package/skills/init/scripts/doctor.mjs +176 -0
- package/skills/spec-child/SKILL.md +149 -0
- package/skills/spec-ignite/SKILL.md +277 -0
- package/skills/spec-parent/SKILL.md +634 -0
- package/skills/spec-pipeline/SKILL.md +453 -0
- package/skills/spec-retro/SKILL.md +398 -0
- package/skills/spec-review/SKILL.md +266 -0
- package/skills/spec-seat/SKILL.md +512 -0
- package/skills/spec-witness/SKILL.md +222 -0
- package/templates/claude-md-stanza.md +13 -0
- package/templates/docs/_meta/doc-index.md +90 -0
- package/templates/docs/infrastructure/background-work.md +53 -0
- package/templates/docs/infrastructure/realtime-events.md +54 -0
- package/templates/docs/patterns/backend-patterns.md +96 -0
- package/templates/docs/patterns/codegen.md +69 -0
- package/templates/docs/patterns/frontend-patterns.md +92 -0
- package/templates/docs/patterns/state-management.md +73 -0
- package/templates/docs/patterns/testing.md +103 -0
- package/templates/docs/patterns/ui-style-guide.md +76 -0
- package/templates/docs/patterns/vertical-slice-anatomy.md +61 -0
- package/templates/docs/readme-archive.md +9 -0
- package/templates/docs/readme-features.md +7 -0
- package/templates/docs/readme-guides.md +7 -0
- package/templates/docs/readme-infrastructure.md +7 -0
- package/templates/docs/readme-patterns.md +9 -0
- package/templates/docs/readme-research.md +9 -0
- package/templates/docs/readme-working.md +9 -0
- package/templates/settings-pin.json +10 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "engineering-loop",
|
|
3
|
+
"owner": { "name": "naswerks" },
|
|
4
|
+
"metadata": {
|
|
5
|
+
"description": "The engineering loop as a Claude Code plugin: docs-* (knowledge), spec-* (execution), init."
|
|
6
|
+
},
|
|
7
|
+
"plugins": [
|
|
8
|
+
{
|
|
9
|
+
"name": "naswerks",
|
|
10
|
+
"description": "The engineering loop: docs-* knowledge loop, spec-* execution loop, and init for a fresh repository.",
|
|
11
|
+
"version": "0.1.0",
|
|
12
|
+
"source": "./"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "naswerks",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The engineering loop: a knowledge loop (docs-*) and an execution loop (spec-*) for repositories driven by hosted agent sessions, plus init to make a repository ready for both.",
|
|
5
|
+
"author": { "name": "naswerks" },
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": ["skills", "engineering-loop", "spec-pipeline", "docs"]
|
|
8
|
+
}
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are recorded here. The format follows Keep a Changelog; the
|
|
4
|
+
version is the one in `package.json` and `.claude-plugin/plugin.json`, which must agree.
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-09-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- The fourteen loop skills as a plugin: `docs-audit-feature`, `docs-audit-full`, `docs-backlog`,
|
|
13
|
+
`docs-process`, `docs-status`, `docs-write`, `spec-child`, `spec-ignite`, `spec-parent`,
|
|
14
|
+
`spec-pipeline`, `spec-retro`, `spec-review`, `spec-seat`, `spec-witness`.
|
|
15
|
+
- `references/docs-workflow.md` and `references/engineering-loop.md`, the method documents a
|
|
16
|
+
repository copies into `docs/_meta/`, each stamped with the pack version.
|
|
17
|
+
- `bin/verify-staged.mjs`, the staging receipt: proves every path handed to `git add` actually
|
|
18
|
+
landed, naming the ignore rule that ate any that did not.
|
|
19
|
+
- `init`: makes a repository ready for both loops. Creates the `docs/` shape with a README per folder,
|
|
20
|
+
copies the two method documents with the pack's version stamp, writes `docs/_meta/doc-index.md` with
|
|
21
|
+
the sections the skills read, drafts first versions of every document the skills send a reader to by
|
|
22
|
+
scanning the repository (and the four stack-conditional ones only where the stack is found), adds
|
|
23
|
+
the start-here stanza to `CLAUDE.md` and `AGENTS.md` where they exist, and the plugin pin to
|
|
24
|
+
`.claude/settings.json`. Idempotent: a second run writes nothing and reports. `init doctor`
|
|
25
|
+
(`skills/init/scripts/doctor.mjs`) grades a repository FAIL / DEGRADE / ok per row.
|
|
26
|
+
- The skills describe the method for any repository: repository-specific knowledge moved to the
|
|
27
|
+
documents `init` writes, one plane's retired vocabulary left, hosted-control-plane recipes are marked
|
|
28
|
+
as such.
|
|
29
|
+
- The pins (`npm test`): manifest and package versions agree, every skill directory carries a
|
|
30
|
+
`SKILL.md` whose `name` is its folder, no byte-order marks, the vocabulary censuses, and the content
|
|
31
|
+
censuses (no private product or dated incident, no glyphs, no link into an archived effort, every
|
|
32
|
+
document a skill names is one `init` writes, the parser contracts a control plane reads).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 naswerks
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# engineering-loop
|
|
2
|
+
|
|
3
|
+
The engineering loop as a Claude Code plugin: a **knowledge loop** (`docs-*`) that keeps a repository's
|
|
4
|
+
living documentation true, and an **execution loop** (`spec-*`) that turns an approved plan into a numbered
|
|
5
|
+
pipeline of agent sessions with a coordinator, build seats, a review seat and a retrospective. `init`
|
|
6
|
+
makes a fresh repository ready for both.
|
|
7
|
+
|
|
8
|
+
The package root is the plugin root: `.claude-plugin/` and `skills/` sit at the top, so one artifact serves
|
|
9
|
+
a person's CLI, a hosted runner, and a repository's pin.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
**In your own Claude Code** (every repository on the machine):
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
claude plugin marketplace add naswerks/engineering-loop
|
|
17
|
+
claude plugin install naswerks@engineering-loop
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Skills are namespaced by the plugin: `/naswerks:docs-write`, `/naswerks:spec-pipeline`, `/naswerks:init`.
|
|
21
|
+
|
|
22
|
+
**On a runner** (sessions started through the Agent SDK load a plugin only from a local path):
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
npm i -g @naswerks/engineering-loop
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
then point the runner at `$(npm root -g)/@naswerks/engineering-loop` — with periscope, the
|
|
29
|
+
`PERISCOPE_PLUGIN_DIRS` key, or the Plugins field on the control plane's runner page.
|
|
30
|
+
|
|
31
|
+
**In a repository** (so everyone who clones it gets the loop): `/naswerks:init` writes the `docs/`
|
|
32
|
+
structure the skills read, the two method documents, a `CLAUDE.md` pointer and the plugin pin in
|
|
33
|
+
`.claude/settings.json`. A team member still installs the plugin once per machine; the pin enables it.
|
|
34
|
+
|
|
35
|
+
## What is in the box
|
|
36
|
+
|
|
37
|
+
| Skill | Loop | One line |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `docs-backlog` | docs | park an idea as a research doc |
|
|
40
|
+
| `docs-status` | docs | orient at session start: archived, in flight, waiting |
|
|
41
|
+
| `docs-write` | docs | finalize the session's working doc |
|
|
42
|
+
| `docs-process` | docs | patch living docs from working docs, verified against code, then archive |
|
|
43
|
+
| `docs-audit-feature` · `docs-audit-full` | docs | audit living docs against code |
|
|
44
|
+
| `spec-pipeline` | spec | explode an approved plan into numbered specs |
|
|
45
|
+
| `spec-parent` | spec | the coordinator seat |
|
|
46
|
+
| `spec-seat` | spec | the contract every armed seat follows |
|
|
47
|
+
| `spec-child` | spec | the build job for one spec |
|
|
48
|
+
| `spec-review` | spec | the outside look before the retrospective |
|
|
49
|
+
| `spec-retro` | spec | the coordinator's close-out audit |
|
|
50
|
+
| `spec-witness` | spec | a read-only verifier |
|
|
51
|
+
| `spec-ignite` | spec | the ignition copilot for a hosted run |
|
|
52
|
+
| `init` | — | make a repository ready for both loops |
|
|
53
|
+
|
|
54
|
+
`references/` carries the two method documents a repository copies into `docs/_meta/`;
|
|
55
|
+
`bin/verify-staged` is the staging receipt the seats run between `git add` and `git commit`. A
|
|
56
|
+
marketplace install puts nothing on PATH: `npm i -g @naswerks/engineering-loop` does, and the skills
|
|
57
|
+
carry the fallback `node <pack root>/bin/verify-staged.mjs`.
|
|
58
|
+
|
|
59
|
+
Running a skill through a child `claude -p` from inside another Claude Code session: scrub the
|
|
60
|
+
`CLAUDE_CODE_*` variables from the child's environment and name the repository root in the prompt,
|
|
61
|
+
or the child may resolve `docs/` paths against the parent session's workspace.
|
|
62
|
+
|
|
63
|
+
## Does it work
|
|
64
|
+
|
|
65
|
+
`npm test` runs the pins: one version across the package, the plugin manifest and the marketplace
|
|
66
|
+
entry; every skill directory carries a `SKILL.md` named for its folder; no byte-order marks; the
|
|
67
|
+
method documents carry the version stamp; the vocabulary censuses. `node bin/verify-staged.mjs
|
|
68
|
+
--self-test` drives the receipt against a throwaway repository and reports each case.
|
|
69
|
+
|
|
70
|
+
## Releasing
|
|
71
|
+
|
|
72
|
+
The version lives in `package.json` and `.claude-plugin/plugin.json` (they must agree). A `v*` tag
|
|
73
|
+
runs the pins and publishes to npm through trusted publishing; the GitHub release notes are the
|
|
74
|
+
CHANGELOG section for that version.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT.
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The staging receipt.
|
|
3
|
+
//
|
|
4
|
+
// `git add <dir>` skips gitignored children with exit 0 and no output, and `git status` cannot show
|
|
5
|
+
// the loss: an ignored file is neither staged nor dirty, so a silently skipped add makes status look
|
|
6
|
+
// CLEANER, not worse. Run this between `git add` and `git commit` over the SAME path list you gave to
|
|
7
|
+
// `git add`; a non-zero exit names every file that did not land and the ignore rule that ate it.
|
|
8
|
+
//
|
|
9
|
+
// verify-staged docs/research/topic/sessions/slug src/x.cs
|
|
10
|
+
// verify-staged --from-file .staging-list.txt --against head
|
|
11
|
+
// verify-staged --self-test
|
|
12
|
+
//
|
|
13
|
+
// States. Red: IGNORED, NEVER-ADDED, NOT-STAGED, MISSING-FROM-DISK, TOO-BROAD, ERROR.
|
|
14
|
+
// Green (notes): STAGED, IN-HEAD, ALREADY-COMMITTED-CLEAN.
|
|
15
|
+
//
|
|
16
|
+
// Deliberately not a fix for .gitignore: widening or anchoring a repository's ignore rules is a
|
|
17
|
+
// repository decision, and force-adding against one is a ruling, not a default.
|
|
18
|
+
//
|
|
19
|
+
// Paths with embedded newlines are not supported (line-delimited git output, core.quotepath=false).
|
|
20
|
+
|
|
21
|
+
import { execFileSync } from 'node:child_process';
|
|
22
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import { isAbsolute, join, sep } from 'node:path';
|
|
25
|
+
|
|
26
|
+
const DIR_EXPANSION_CAP = 2000;
|
|
27
|
+
|
|
28
|
+
function git(root, args) {
|
|
29
|
+
try {
|
|
30
|
+
const out = execFileSync('git', ['-C', root, '-c', 'core.quotepath=false', ...args], {
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
33
|
+
});
|
|
34
|
+
return { ok: true, exit: 0, lines: out.split(/\r?\n/).filter((l) => l.length > 0) };
|
|
35
|
+
} catch (e) {
|
|
36
|
+
const out = typeof e.stdout === 'string' ? e.stdout : '';
|
|
37
|
+
return { ok: false, exit: typeof e.status === 'number' ? e.status : 1, lines: out.split(/\r?\n/).filter((l) => l.length > 0) };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toRepoRelative(root, input) {
|
|
42
|
+
let p = input.trim().replace(/\\/g, '/').replace(/\/+$/, '');
|
|
43
|
+
if (isAbsolute(p) || /^[A-Za-z]:\//.test(p)) {
|
|
44
|
+
const rootNorm = root.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
45
|
+
if (!p.toLowerCase().startsWith(rootNorm.toLowerCase())) return null;
|
|
46
|
+
p = p.slice(rootNorm.length).replace(/^\/+/, '');
|
|
47
|
+
}
|
|
48
|
+
return p.length === 0 ? null : p;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function testStagedList(root, paths, mode = 'index') {
|
|
52
|
+
const findings = [];
|
|
53
|
+
const notes = [];
|
|
54
|
+
const top = git(root, ['rev-parse', '--show-toplevel']);
|
|
55
|
+
if (!top.ok || top.lines.length === 0) {
|
|
56
|
+
findings.push({ file: root, state: 'ERROR', detail: 'not inside a git repository (rev-parse --show-toplevel failed)' });
|
|
57
|
+
return { ok: false, findings, notes, expected: 0, green: 0 };
|
|
58
|
+
}
|
|
59
|
+
const repoTop = top.lines[0];
|
|
60
|
+
const igc = git(root, ['config', 'core.ignorecase']);
|
|
61
|
+
const caseInsensitive = igc.ok && igc.lines[0]?.trim() === 'true';
|
|
62
|
+
const key = (s) => (caseInsensitive ? s.toLowerCase() : s);
|
|
63
|
+
const setOf = (lines) => new Set(lines.map(key));
|
|
64
|
+
|
|
65
|
+
const tracked = setOf(git(root, ['ls-files', '--cached']).lines);
|
|
66
|
+
let head = null;
|
|
67
|
+
let staged = null;
|
|
68
|
+
let dirty = null;
|
|
69
|
+
if (mode === 'head') {
|
|
70
|
+
const lsTree = git(root, ['ls-tree', '-r', '--name-only', 'HEAD']);
|
|
71
|
+
head = setOf(lsTree.lines);
|
|
72
|
+
if (!lsTree.ok) notes.push('HEAD is unreadable (empty repository?) - every expected file will read as missing from HEAD');
|
|
73
|
+
} else {
|
|
74
|
+
staged = setOf(git(root, ['diff', '--cached', '--no-renames', '--name-only']).lines);
|
|
75
|
+
dirty = setOf(git(root, ['diff', '--name-only']).lines);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const expected = [];
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
for (const raw of paths) {
|
|
81
|
+
if (!raw || raw.trim().length === 0) continue;
|
|
82
|
+
const rel = toRepoRelative(repoTop, raw);
|
|
83
|
+
if (rel === null) {
|
|
84
|
+
findings.push({ file: raw, state: 'ERROR', detail: 'path is outside the repository (or empty after normalization)' });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const onDisk = join(repoTop, rel.split('/').join(sep));
|
|
88
|
+
if (existsSync(onDisk) && statSync(onDisk).isDirectory()) {
|
|
89
|
+
const inDir = [];
|
|
90
|
+
for (const spec of [
|
|
91
|
+
['ls-files', '--cached', '--', rel],
|
|
92
|
+
['ls-files', '--others', '--exclude-standard', '--', rel],
|
|
93
|
+
['ls-files', '--others', '--ignored', '--exclude-standard', '--', rel],
|
|
94
|
+
]) {
|
|
95
|
+
inDir.push(...git(root, spec).lines);
|
|
96
|
+
}
|
|
97
|
+
if (inDir.length > DIR_EXPANSION_CAP) {
|
|
98
|
+
findings.push({ file: rel, state: 'TOO-BROAD', detail: `directory expands to ${inDir.length} files (cap ${DIR_EXPANSION_CAP}) - name explicit paths` });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (inDir.length === 0) {
|
|
102
|
+
findings.push({ file: rel, state: 'MISSING-FROM-DISK', detail: 'directory exists but contains no files git can see - an empty filing is a citation to nothing' });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
for (const f of inDir) if (!seen.has(key(f))) { seen.add(key(f)); expected.push(f); }
|
|
106
|
+
} else if (!seen.has(key(rel))) {
|
|
107
|
+
seen.add(key(rel));
|
|
108
|
+
expected.push(rel);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let green = 0;
|
|
113
|
+
for (const f of expected) {
|
|
114
|
+
const onDisk = join(repoTop, f.split('/').join(sep));
|
|
115
|
+
const exists = existsSync(onDisk) && statSync(onDisk).isFile();
|
|
116
|
+
const k = key(f);
|
|
117
|
+
if (mode === 'head') {
|
|
118
|
+
if (head.has(k)) { green++; continue; }
|
|
119
|
+
} else if (staged.has(k)) {
|
|
120
|
+
green++;
|
|
121
|
+
continue;
|
|
122
|
+
} else if (tracked.has(k) && !dirty.has(k)) {
|
|
123
|
+
notes.push(`already committed, clean: ${f}`);
|
|
124
|
+
green++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (mode !== 'head' && tracked.has(k)) {
|
|
128
|
+
findings.push({ file: f, state: 'NOT-STAGED', detail: 'tracked and MODIFIED (or deleted) but the change was never added' });
|
|
129
|
+
} else if (exists) {
|
|
130
|
+
const ci = git(root, ['check-ignore', '-v', '--', f]);
|
|
131
|
+
if (ci.exit === 0 && ci.lines.length > 0) {
|
|
132
|
+
const rule = ci.lines[0].split('\t')[0];
|
|
133
|
+
findings.push({ file: f, state: 'IGNORED', detail: `eaten by ${rule} - git add skipped it with exit 0 and NO output` });
|
|
134
|
+
} else {
|
|
135
|
+
findings.push({ file: f, state: 'NEVER-ADDED', detail: 'on disk, not ignored, and not staged - the add never named it (path typo, or the add ran from the wrong directory?)' });
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
findings.push({ file: f, state: 'MISSING-FROM-DISK', detail: 'named in the list but no such file exists - a committed citation to this path would point at nothing' });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { ok: findings.length === 0, findings, notes, expected: expected.length, green };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function report(result, mode, pathCount) {
|
|
145
|
+
for (const n of result.notes) console.log(` note: ${n}`);
|
|
146
|
+
if (result.ok) {
|
|
147
|
+
console.log(`STAGING VERIFIED: ${pathCount} path(s) -> ${result.expected} file(s), all accounted for (against ${mode}).`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
console.log(`STAGING INCOMPLETE: ${result.findings.length} of ${result.expected} expected file(s) are NOT accounted for (against ${mode}).`);
|
|
151
|
+
for (const f of result.findings) {
|
|
152
|
+
console.log(` MISSING (${f.state}): ${f.file}`);
|
|
153
|
+
console.log(` ${f.detail}`);
|
|
154
|
+
}
|
|
155
|
+
console.log(' A silently-skipped add is INVISIBLE to git status. Do NOT commit.');
|
|
156
|
+
console.log(' Moves: rename the artifact off the ignored pattern, or raise the question for a ruling.');
|
|
157
|
+
console.log(' git add -f is a ruling, not a default, and .gitignore is never edited from a seat.');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function selfTest() {
|
|
161
|
+
const tmp = mkdtempSync(join(tmpdir(), 'verify-staged-selftest-'));
|
|
162
|
+
const cases = [];
|
|
163
|
+
try {
|
|
164
|
+
git(tmp, ['init', '-q']);
|
|
165
|
+
writeFileSync(join(tmp, '.gitignore'), 'bin/\n*.log\n');
|
|
166
|
+
mkdirSync(join(tmp, 'docs', 'session'), { recursive: true });
|
|
167
|
+
mkdirSync(join(tmp, 'src', 'bin'), { recursive: true });
|
|
168
|
+
writeFileSync(join(tmp, 'docs', 'session', 'a.md'), 'artifact\n');
|
|
169
|
+
writeFileSync(join(tmp, 'docs', 'session', 'receipt.log'), 'receipt\n');
|
|
170
|
+
writeFileSync(join(tmp, 'src', 'bin', 'tool.txt'), 'composition root\n');
|
|
171
|
+
writeFileSync(join(tmp, 'loose.txt'), 'never added\n');
|
|
172
|
+
git(tmp, ['add', 'docs/session', 'src']);
|
|
173
|
+
|
|
174
|
+
cases.push({ name: '*.log eats a receipt under an added dir', shouldFail: true, result: testStagedList(tmp, ['docs/session']), expect: 'IGNORED', detail: '.gitignore:2' });
|
|
175
|
+
cases.push({ name: 'unanchored bin/ eats src/bin at depth', shouldFail: true, result: testStagedList(tmp, ['src']), expect: 'IGNORED', detail: '.gitignore:1' });
|
|
176
|
+
cases.push({ name: 'a not-ignored miss reads NEVER-ADDED', shouldFail: true, result: testStagedList(tmp, ['loose.txt']), expect: 'NEVER-ADDED', detail: '' });
|
|
177
|
+
cases.push({ name: 'a citation to a path that does not exist', shouldFail: true, result: testStagedList(tmp, ['docs/session/scratch-notes.md']), expect: 'MISSING-FROM-DISK', detail: '' });
|
|
178
|
+
cases.push({ name: 'positive control: a staged file verifies green', shouldFail: false, result: testStagedList(tmp, ['docs/session/a.md']) });
|
|
179
|
+
git(tmp, ['-c', 'user.name=selftest', '-c', 'user.email=selftest@local', 'commit', '-q', '-m', 'selftest']);
|
|
180
|
+
cases.push({ name: 'positive control: committed-clean is a note, not a red', shouldFail: false, result: testStagedList(tmp, ['docs/session/a.md']) });
|
|
181
|
+
cases.push({ name: 'head mode: the eaten receipt is absent from the commit', shouldFail: true, result: testStagedList(tmp, ['docs/session'], 'head'), expect: 'IGNORED', detail: '' });
|
|
182
|
+
|
|
183
|
+
let failed = 0;
|
|
184
|
+
for (const c of cases) {
|
|
185
|
+
let verdict;
|
|
186
|
+
if (c.shouldFail) {
|
|
187
|
+
const hit = c.result.findings.filter((f) => f.state === c.expect);
|
|
188
|
+
if (c.result.ok) verdict = 'DID NOT FIRE - the detector is broken';
|
|
189
|
+
else if (hit.length === 0) verdict = `fired with the WRONG state (wanted ${c.expect})`;
|
|
190
|
+
else if (c.detail && !hit[0].detail.includes(c.detail)) verdict = `fired without naming the rule (wanted '${c.detail}' in: ${hit[0].detail})`;
|
|
191
|
+
else verdict = 'PASS - fired, named correctly';
|
|
192
|
+
} else {
|
|
193
|
+
verdict = c.result.ok ? 'PASS - stayed green on good input' : 'FIRED on good input - false positive';
|
|
194
|
+
}
|
|
195
|
+
if (!verdict.startsWith('PASS')) failed++;
|
|
196
|
+
console.log(` [${verdict.startsWith('PASS') ? 'PASS' : 'FAIL'}] ${c.name}: ${verdict}`);
|
|
197
|
+
}
|
|
198
|
+
if (failed === 0) {
|
|
199
|
+
console.log('SELF-TEST PASSED: the detector fires on both ignore sightings, discriminates never-added from ignored, and stays silent on good input.');
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
console.log(`SELF-TEST FAILED: ${failed} case(s) - do not trust this detector until it fires correctly.`);
|
|
203
|
+
return 1;
|
|
204
|
+
} finally {
|
|
205
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function main(argv) {
|
|
210
|
+
const paths = [];
|
|
211
|
+
let fromFile = '';
|
|
212
|
+
let against = 'index';
|
|
213
|
+
let root = process.cwd();
|
|
214
|
+
for (let i = 0; i < argv.length; i++) {
|
|
215
|
+
const a = argv[i];
|
|
216
|
+
if (a === '--self-test') return selfTest();
|
|
217
|
+
if (a === '--from-file') { fromFile = argv[++i] ?? ''; continue; }
|
|
218
|
+
if (a === '--against') { against = (argv[++i] ?? 'index').toLowerCase(); continue; }
|
|
219
|
+
if (a === '--repo-root') { root = argv[++i] ?? root; continue; }
|
|
220
|
+
paths.push(a);
|
|
221
|
+
}
|
|
222
|
+
if (against !== 'index' && against !== 'head') {
|
|
223
|
+
console.log(`STAGING INCOMPLETE: --against must be index or head, not '${against}'.`);
|
|
224
|
+
return 1;
|
|
225
|
+
}
|
|
226
|
+
if (fromFile) {
|
|
227
|
+
if (!existsSync(fromFile)) {
|
|
228
|
+
console.log(`STAGING INCOMPLETE: --from-file '${fromFile}' does not exist - the staging list itself is missing.`);
|
|
229
|
+
return 1;
|
|
230
|
+
}
|
|
231
|
+
for (const line of readFileSync(fromFile, 'utf8').split(/\r?\n/)) {
|
|
232
|
+
const t = line.trim();
|
|
233
|
+
if (t.length > 0 && !t.startsWith('#')) paths.push(t);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (paths.length === 0) {
|
|
237
|
+
console.log('STAGING INCOMPLETE: no paths were given - an empty staging list verifies nothing (pass the SAME list you gave to git add).');
|
|
238
|
+
return 1;
|
|
239
|
+
}
|
|
240
|
+
const result = testStagedList(root, paths, against);
|
|
241
|
+
report(result, against, paths.length);
|
|
242
|
+
return result.ok ? 0 : 1;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (process.argv[1] && /verify-staged\.mjs$/.test(process.argv[1].replace(/\\/g, '/'))) {
|
|
246
|
+
process.exit(main(process.argv.slice(2)));
|
|
247
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@naswerks/engineering-loop",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The engineering loop as a Claude Code plugin: a knowledge loop (docs-*), an execution loop (spec-*), and init for a fresh repository. The package root is the plugin root.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"verify-staged": "bin/verify-staged.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
".claude-plugin/",
|
|
15
|
+
"skills/",
|
|
16
|
+
"references/",
|
|
17
|
+
"templates/",
|
|
18
|
+
"bin/",
|
|
19
|
+
"README.md",
|
|
20
|
+
"CHANGELOG.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test \"pins/*.test.mjs\"",
|
|
25
|
+
"check:publish": "node pins/publish-gate.mjs",
|
|
26
|
+
"verify-staged": "node bin/verify-staged.mjs"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/naswerks/engineering-loop.git"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
}
|
|
35
|
+
}
|