Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,16 @@ Every page uses `templates/page.md`. Non-negotiable rules:
1. **One case per page.** A page answers one situation. If you are writing "and also…",
split the page and cross-link under `related`.
2. **≤ 120 lines of body.** Precision beats coverage. Link, don't inline.
3. **Positive guidance only.** Every directive is "In situation X, do Y".
Anti-patterns may only appear in the `Instead of` table, where each row MUST pair
the anti-pattern with its replacement action. A "don't" without an "instead" is a
lint failure — a prohibition with no replacement invites the reader to improvise,
which is how hallucinations happen.
3. **Positive guidance; a prohibition must pair.** Every directive is "In situation X,
do Y". A prohibition (`don't` / `do not` / `never` / `avoid` / `must not`) may
appear anywhere in a page, as long as the same directive item also carries its
replacement action or the mechanism that makes the prohibition true. The
`Instead of` table remains the place for anti-pattern/replacement pairs; each row
there MUST still pair the anti-pattern with its replacement action. The unit of
pairing is the directive item — a table cell or a bullet — not the page as a
whole. A bare prohibition, alone in its item, is a lint failure: a prohibition
with no replacement invites the reader to improvise, which is how hallucinations
happen. Enforced by `node scripts/wiki-lint-prohibitions.js`.
4. **No vague qualifiers.** Words like "usually", "consider", "might want to",
"generally", "as appropriate" are banned in directive sentences. State the
condition that decides it: "When X, do A. When Y, do B." If you cannot state the
Expand Down Expand Up @@ -126,8 +131,8 @@ Run these via the skill files, which contain the full step-by-step workflows:
merge into existing pages before creating new ones, cite sources, update indexes and `log.md`.
- **Query** (`skills/wiki-query/SKILL.md`) — answer a question from the wiki with citations;
if the answer required synthesis across pages and is re-askable, file it as a new page.
- **Lint** (`skills/wiki-lint/SKILL.md`) — health check: unsourced claims, "don't"s without
"instead"s, banned vague qualifiers, orphan pages, broken links, stale `last_verified`.
- **Lint** (`skills/wiki-lint/SKILL.md`) — health check: unsourced claims, unpaired
prohibitions, banned vague qualifiers, orphan pages, broken links, stale `last_verified`.

Two further skills use the wiki to run development work (rather than maintain the wiki):

Expand Down
2 changes: 1 addition & 1 deletion INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ follow the cross-pointers in their index or take the next matching seeded domain
| Domain | Status | Route here when |
|--------|--------|-----------------|
| [databases](wiki/databases/index.md) | **seeded** | Designing schemas/tables/keys, choosing or evaluating indexes, writing or optimizing queries, choosing transaction/isolation behavior, surveying live data to derive a rule, verifying additive migrations |
| [backend](wiki/backend/index.md) | **seeded** | Server-side application code — language-agnostic (`common/`: API contracts, call-site enumeration before a contract change, idempotency, JWT, timeouts/retries, caching, jobs, transactions in app code, shared state/pools, errors, LLM completion validation & context budgeting, consuming external-API responses, externally-owned defaults, object-storage references) plus stack subtrees: `java/` (JPA, Spring proxies, JVM threads/memory), `node/` (event loop, promises, runtime validation, shutdown), `python/` (GIL/asyncio, pydantic, WSGI/ASGI workers, language traps) |
| [backend](wiki/backend/index.md) | **seeded** | Server-side application code — language-agnostic (`common/`: API contracts, call-site enumeration before a contract change, idempotency, JWT, timeouts/retries, caching, jobs, transactions in app code, shared state/pools, errors, consuming LLM APIs (completion validation, context budgeting), consuming external-API responses, externally-owned defaults, object-storage references) plus stack subtrees: `java/` (JPA, Spring proxies, JVM threads/memory), `node/` (event loop, promises, runtime validation, shutdown), `python/` (GIL/asyncio, pydantic, WSGI/ASGI workers, language traps) |
| [frontend](wiki/frontend/index.md) | **seeded** | Web UI code: state placement, rendering performance, in-UI data fetching (races, infinite scroll), auth token handling, forms, XSS-safe output, accessibility |
| [infrastructure](wiki/infrastructure/index.md) | **seeded** | CI/CD pipelines, secrets in build/deploy, container image builds, rollout/rollback strategy, observability (logs/metrics/alerting), per-environment/path-valued config, multi-agent orchestration (worker liveness signals, shared run state, tmux pane delivery, completion gates, worktree-isolated workers) |
| [testing](wiki/testing/index.md) | **seeded** | Writing or structuring automated tests: level choice, cases/assertions, test data, mock decisions, flaky tests (release-process quality → qa) |
Expand Down
8 changes: 6 additions & 2 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,12 @@ deny-net을 분류기는 볼 수 없기 때문입니다. 코디네이터 세션
발화합니다. 아무것도 하지 않아도 PR이 나타납니다. 가드:
킬 스위치 `DEV_LOOP_AUTOFLUSH=0`, `DEV_LOOP_AUTOFLUSH_INTERVAL`(기본
3600초)당 1회, 대기 항목 `DEV_LOOP_AUTOFLUSH_MIN`(기본 3)개 이상일 때만,
single-flight 잠금, 재귀 안전. `claude` + `gh`가 PATH에 있고 gh 인증이
필요합니다. 하나라도 없으면 조용히 no-op — 수동으로 fallback.
아래 수동 플러시와 공유하는 owner-token single-flight 잠금
(`DEV_LOOP_FLUSH_LOCK_TTL`, 기본 900초 — 이 시간이 지나면 죽은 홀더의
잠금을 재점유 가능), 그리고 두 진입점이 같은 후보를 동시에 인제스트하지
않도록 하는 런당 큐 클레임(`DEV_LOOP_CLAIM_TTL`, 기본 3600초), 재귀
안전. `claude` + `gh`가 PATH에 있고 gh 인증이 필요합니다. 하나라도
없으면 조용히 no-op — 수동으로 fallback.
- **수동** — 언제든 `/dev-loop:knowledge-flush`로 큐를 지금 비웁니다.

### 이 순서는 훅으로 강제됩니다
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,13 @@ The wiki is meant to grow from what you actually learn. Three moving parts:
rate-limit window has elapsed, so PRs appear without you doing anything.
Guarded: kill switch `DEV_LOOP_AUTOFLUSH=0`, once per
`DEV_LOOP_AUTOFLUSH_INTERVAL` (default 3600s), only at
`DEV_LOOP_AUTOFLUSH_MIN` (default 3) pending items, single-flight lock, and
recursion-safe. Needs `claude` + `gh` on PATH and gh authenticated; if either
is missing it silently no-ops and you fall back to manual.
`DEV_LOOP_AUTOFLUSH_MIN` (default 3) pending items, an owner-token
single-flight lock shared with the manual flush below
(`DEV_LOOP_FLUSH_LOCK_TTL`, default 900s, before a crashed holder's lock
is reclaimable) plus a per-run queue claim (`DEV_LOOP_CLAIM_TTL`, default
3600s) so the two entry points never ingest the same candidate, and
recursion-safe. Needs `claude` + `gh` on PATH and gh authenticated; if
either is missing it silently no-ops and you fall back to manual.
- **Manual** — invoke `/dev-loop:knowledge-flush` any time to drain the queue now.

### This ordering is enforced by a hook
Expand Down
29 changes: 20 additions & 9 deletions hooks/auto-flush.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@
# and inside the flush checkout (~/.dev-loop/repo)
# - rate limit: at most once per DEV_LOOP_AUTOFLUSH_INTERVAL sec (default 3600)
# - threshold: only when >= DEV_LOOP_AUTOFLUSH_MIN pending items (default 3)
# - single-flight: a TTL lock dir
# - single-flight: scripts/flush-lock.sh, an owner-token mkdir lock shared
# with skills/knowledge-flush/SKILL.md step 0 (issue #77).
# DEV_LOOP_FLUSH_LOCK_TTL sec before a crashed holder's lock
# is reclaimable (default 900). DEV_LOOP_CLAIM_TTL sec before
# a claimed-but-unfinished queue row (hooks/queue-claim.js)
# is reclaimable by another run (default 3600).
# DEV_LOOP_FLUSH_LOCK (lock path) and DEV_LOOP_FLUSH_RUN_ID
# (this run's id) are test/override seams, not user knobs.
# - fail-safe: if `claude`/`gh` are missing it silently no-ops; the manual
# /dev-loop:knowledge-flush skill still works.
set +e
Expand All @@ -42,6 +49,9 @@ QUEUE="$DIR/queue"
[ -d "$QUEUE" ] || exit 0

# --- threshold: count PENDING rows (exclude the retired .processed.jsonl) --
# The literal '"status":"pending"' match already excludes "claimed" rows
# (hooks/queue-claim.js), so a live sibling run's in-flight claims cannot
# re-trip this threshold.
PENDING=0
for f in "$QUEUE"/*.jsonl; do
case "$f" in *"/.processed.jsonl") continue ;; esac
Expand All @@ -59,13 +69,14 @@ if [ -f "$STAMP" ] && [ -n "$(find "$STAMP" -mmin "-$INTERVAL_MIN" 2>/dev/null)"
exit 0
fi

# --- single-flight lock (TTL ~15 min via mkdir atomicity) -----------------
LOCK="$DIR/.autoflush.lock"
if ! mkdir "$LOCK" 2>/dev/null; then
# stale lock older than 15 min → reclaim
[ -n "$(find "$LOCK" -mmin -15 2>/dev/null)" ] && exit 0
rmdir "$LOCK" 2>/dev/null; mkdir "$LOCK" 2>/dev/null || exit 0
fi
# --- single-flight lock (shared with skills/knowledge-flush/SKILL.md step 0
# via scripts/flush-lock.sh — see issue #77) ---------------------------
# One run id for both the acquire below and the release in the backgrounded
# subshell, so release's owner-token check matches this run (DEV_LOOP_FLUSH_RUN_ID
# is exported here and inherited by the "(...) &" subshell further down).
RUNID="${DEV_LOOP_FLUSH_RUN_ID:-$(date +%Y%m%d-%H%M%S)-$$}"
export DEV_LOOP_FLUSH_RUN_ID="$RUNID"
sh "$(dirname "$0")/../scripts/flush-lock.sh" acquire >/dev/null 2>&1 || exit 0
touch "$STAMP" 2>/dev/null

# --- spawn the detached headless flush ------------------------------------
Expand All @@ -76,7 +87,7 @@ PROMPT='Run the dev-loop:knowledge-flush skill now. Drain ~/.dev-loop/queue: for
DEV_LOOP_FLUSHING=1 nohup "$CLAUDE_BIN" -p "$PROMPT" \
--permission-mode bypassPermissions \
> "$DIR/autoflush.log" 2>&1
rmdir "$LOCK" 2>/dev/null
sh "$(dirname "$0")/../scripts/flush-lock.sh" release >/dev/null 2>&1
) &
disown 2>/dev/null

Expand Down
195 changes: 195 additions & 0 deletions hooks/queue-claim.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
#!/usr/bin/env node
/*
* queue-claim.js — claim/release insight-queue rows by run id (issue #77).
*
* scripts/flush-lock.sh makes the two knowledge-flush entry points mutually
* exclusive, but a lock alone is not proof against a stale actor (a paused or
* TTL-expired holder can resume mid-work). This tool makes the QUEUE
* self-defend: a run claims the rows it is about to ingest, so a second run —
* racing past the lock, or reclaiming after a dead holder — sees those rows
* as unavailable instead of re-ingesting them.
*
* Mutates each *.jsonl row IN PLACE (status "pending" -> "claimed", plus
* claimedBy/claimedAt) rather than moving it to another file — this keeps the
* row in hooks/harvest.js's dedupe seed (which reads the session queue file
* AND .processed.jsonl), so a still-pending duplicate is never re-harvested.
* .processed.jsonl (the retired store) is never touched by any subcommand.
*
* Row identity is hooks/harvest.js's own dedupe key, the "hash" field —
* reused here rather than inventing a second id for the same row.
*
* usage:
* node queue-claim.js list print claimable row ids, one per line
* node queue-claim.js claim [--max N] claim up to N claimable rows, print their ids
* node queue-claim.js release <id>... set specific claimed rows back to pending
*
* env overrides:
* DEV_LOOP_QUEUE_DIR queue directory (default: ~/.dev-loop/queue)
* DEV_LOOP_FLUSH_RUN_ID this run's id (default: <timestamp>-<pid>)
* DEV_LOOP_CLAIM_TTL seconds before a claimed row is reclaimable (default: 3600)
*/
'use strict';

const fs = require('fs');
const path = require('path');
const os = require('os');

function queueDir() {
return process.env.DEV_LOOP_QUEUE_DIR || path.join(os.homedir(), '.dev-loop', 'queue');
}

function defaultRunId() {
const d = new Date();
const pad = (n) => String(n).padStart(2, '0');
const ts =
`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-` +
`${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
return `${ts}-${process.pid}`;
}

function runId() {
return process.env.DEV_LOOP_FLUSH_RUN_ID || defaultRunId();
}

function claimTtlSec() {
const v = parseInt(process.env.DEV_LOOP_CLAIM_TTL, 10);
return Number.isFinite(v) ? v : 3600;
}

// Every *.jsonl in the queue dir except the retired store, sorted for a
// deterministic claim order.
function queueFiles() {
const dir = queueDir();
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter((f) => f.endsWith('.jsonl') && f !== '.processed.jsonl')
.sort()
.map((f) => path.join(dir, f));
}

// Read one queue file into {raw, parsed} rows. `raw` is the exact original
// line — kept byte-for-byte for any row this run does not rewrite, so an
// unparseable line always survives untouched. `parsed` is null for a
// malformed line and for blank lines (which are dropped, matching
// harvest.js's own "non-blank line" convention).
function readRows(file) {
const text = fs.readFileSync(file, 'utf8');
const rows = [];
for (const raw of text.split('\n')) {
if (!raw.trim()) continue;
let parsed = null;
try {
parsed = JSON.parse(raw);
} catch {
parsed = null;
}
rows.push({ raw, parsed });
}
return rows;
}

function writeRowsAtomic(file, rows) {
const body = rows.map((r) => r.raw).join('\n') + '\n';
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, body);
fs.renameSync(tmp, file);
}

function isClaimable(row, ttlSec, nowMs) {
if (!row || !row.hash) return false;
if (row.status === 'pending') return true;
if (row.status === 'claimed') {
const claimedMs = Date.parse(row.claimedAt);
return nowMs - claimedMs > ttlSec * 1000;
}
return false;
}

function cmdList() {
const nowMs = Date.now();
const ttlSec = claimTtlSec();
const ids = [];
for (const file of queueFiles()) {
for (const { parsed } of readRows(file)) {
if (isClaimable(parsed, ttlSec, nowMs)) ids.push(parsed.hash);
}
}
if (ids.length) process.stdout.write(ids.join('\n') + '\n');
}

function cmdClaim(argv) {
let max = Infinity;
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--max') {
const n = parseInt(argv[i + 1], 10);
if (Number.isFinite(n)) max = n;
i++;
}
}

const nowMs = Date.now();
const ttlSec = claimTtlSec();
const claimedAt = new Date(nowMs).toISOString();
const who = runId();
const claimedIds = [];
let remaining = max;

for (const file of queueFiles()) {
const rows = readRows(file);
let changed = false;
for (const row of rows) {
if (remaining <= 0) break;
if (!isClaimable(row.parsed, ttlSec, nowMs)) continue;
row.parsed.status = 'claimed';
row.parsed.claimedBy = who;
row.parsed.claimedAt = claimedAt;
row.raw = JSON.stringify(row.parsed);
changed = true;
remaining -= 1;
claimedIds.push(row.parsed.hash);
}
if (changed) writeRowsAtomic(file, rows);
}

if (claimedIds.length) process.stdout.write(claimedIds.join('\n') + '\n');
}

function cmdRelease(argv) {
const ids = new Set(argv);
if (!ids.size) return;

for (const file of queueFiles()) {
const rows = readRows(file);
let changed = false;
for (const row of rows) {
if (!row.parsed || !row.parsed.hash || !ids.has(row.parsed.hash)) continue;
row.parsed.status = 'pending';
delete row.parsed.claimedBy;
delete row.parsed.claimedAt;
row.raw = JSON.stringify(row.parsed);
changed = true;
}
if (changed) writeRowsAtomic(file, rows);
}
}

function main() {
const [cmd, ...rest] = process.argv.slice(2);
switch (cmd) {
case 'list':
cmdList();
break;
case 'claim':
cmdClaim(rest);
break;
case 'release':
cmdRelease(rest);
break;
default:
process.stderr.write('usage: queue-claim.js list|claim [--max N]|release <id>...\n');
process.exit(2);
}
}

main();
1 change: 1 addition & 0 deletions log.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ Append-only. Format: `## [YYYY-MM-DD] <ingest|revise|lint|gap|contradiction|drif
## [2026-08-06] revise | Union-merged amendments from the same 15 PRs into 24 existing pages (largest: tests-that-cannot-fail — 7 PRs: per-assertion mutation granularity, restore-mechanism-by-commit-state, suite-total-drop detection; portable-shell-scripts — 8 PRs: POSIX inline set-- reordering, ${VAR:-} vs ${VAR-} colon semantics, quote-by-what-the-text-is; test-data-and-isolation — 6 PRs: env-derived write paths, absent-variable cases, exec-bit fixtures, leak attribution; change-impact/call-site-enumeration — 8 unique additions from 7 path-variants: rename-in-place, same-type reorder, *args forwarding, two-searches-agree fallacy, codemod, coverage completion check). All non-canonical cross-page ids remapped to canonical pages; every added line traced to its source PR hunk (fabricated worker output was detected by orphan-line verification and replaced with true PR content).
## [2026-08-06] dedup | Cross-PR duplicate clusters collapsed: client-side rate limiting (8 PR versions at 3 paths → 1), call-site enumeration (7 versions at 7 paths → additions folded into the already-merged canonical page), stderr/exit-0 diagnostics (4 versions at 4 paths → 1), macOS sysroot (2→1), env-var off switch (2→1), completion predicates (2→1), robots.txt (2→1), harness-mediated results (2→1), leaked test artifacts (2→1), orchestration category naming (orchestration vs agent-orchestration → agent-orchestration). Kept distinct after trigger comparison: differential-testing vs differential-run-agreement (setup vs interpretation), unset-versus-empty-parameters vs env-var-off-switches (semantics vs design, cross-linked), import-time-side-effects vs test-level-choice edge (tactics vs level choice, cross-linked). Merged-main near-dup scan (Jaccard over title+trigger, 141 pages): 0 pairs — no duplication among previously merged content.
## [2026-08-06] ingest | Reconciled post-consolidation flush PRs #42–#43 (created against pre-#44 main). Folded unique content: platforms/filesystems/permissions-and-exec-bits (+`sh "$SCRIPT"` interpreter-invocation rows — stubs need no exec bit, avoids EDR chmod+x heuristics), platforms/shells/command-text-inspected-before-execution (+gate-AUTHOR step 8: parse all three POSIX quoting forms, expand only ~/$HOME/${HOME}; red-then-green bats evidence), testing/quality/checks-that-cannot-pass (+vacuously-green pre-implementation test row), platforms/processes/tool-diagnostics-without-a-failing-exit-code (+repeat-suppression edge, +clang reproduction source), infrastructure/ci-cd/secrets-handling (+independent credential channels row: gh token vs git SSH vs API token, git push --dry-run / author:@me probes), testing/mocking/what-to-mock (+ESM read-only-bindings DI rows, +PATH-emptied tripwire negative-proof). Dropped as duplicates of the #44 state: #42's rate-limit row (already in timeouts-and-retries + client-side-rate-limiting), #42's warning-only-diagnostics page (same trigger as tool-diagnostics-without-a-failing-exit-code), #42's leak-attribution row (already in test-data-and-isolation).
## [2026-08-12] revise | routing: disjoint scopes for the doc-gate cluster (testing/quality ↔ qa/document-verification) and the flaky pair (testing/flaky ↔ debugging/concurrency); INDEX backend LLM phrasing; databases→backup cross-pointer (#37)
Loading
Loading